﻿<#
.SYNOPSIS
Compress and base 64 encode bytes

.PARAMETER data
data to compress

.EXAMPLE
Compress-_Resource -data @(1,2,3)

 returns the encoded data: H4sIAAAAAAAEAGNkYgYAHYC8VQMAAAA=

.OUTPUTS
base 64 encoded and compressed bytes [string]
#>
function Compress-_Resource ( [parameter(Mandatory=$false, ValueFromPipeline=$false)]
                              [byte[]]$data
                             )
{
   if ($data -ne $null)
   {
     $compressedDataStream = New-Object System.IO.MemoryStream
     $compressor = New-Object System.IO.Compression.GZipStream($compressedDataStream, [System.IO.Compression.CompressionMode]::Compress)
     $compressor.Write($data,0,$data.Length)
     $compressor.Close()
    
     return [System.Convert]::ToBase64String($compressedDataStream.ToArray())
   }

   return [String]::Empty
}

<#
.SYNOPSIS
Build an in-memory version of the PowerShell module installer

.DESCRIPTION
Creates a fully functional in-memory version of the installer including all properties and
script methods to unpack and deploy the embedded files

.PARAMETER moduleFolder
Location of the files constituting the module

.PARAMETER manifest
the in-memory version of the module manifest

.PARAMETER packingList
the list of files constituting the module

.PARAMETER licenseAgreement
location of license agreement to display on installation 

.OUTPUTS
Installer Instance [PSCustomObject]

#>
function New-_PSModuleInstallerInstance ( [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                                          [ValidateNotNull()]
                                          [ValidateScript({$_.Exists})]
                                          [System.IO.DirectoryInfo]$moduleFolder,
                                         
                                          [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                                          [ValidateNotNull()]
                                          [HashTable]$manifest,
                                         
                                          [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                                          [ValidateNotNull()]
                                          [string[]]$packingList,
                                         
                                          [parameter(Mandatory=$false, ValueFromPipeline=$false)]
                                          [Validatescript({$_ -eq $null -or $_.Exists})]
                                          [System.IO.FileInfo]$licenseAgreement
                                       )
{
  [PSCustomObject]$installer = New-Object PSCustomObject
  
  ## Add the properties
  Add-Member -InputObject $installer -MemberType NoteProperty -name Name                  -Value $moduleFolder.Name
  Add-Member -InputObject $installer -MemberType NoteProperty -name PackagedVersion       -Value $manifest.ModuleVersion
  Add-Member -InputObject $installer -MemberType NoteProperty -name Description           -Value $manifest.Description
  Add-Member -InputObject $installer -MemberType NoteProperty -name Publisher             -Value $manifest.CompanyName
  Add-Member -InputObject $installer -MemberType NoteProperty -name ID                    -Value $manifest.GUID
  [string]$arch = 'any'
  if (![string]::IsNullOrEmpty($manifest.ProcessorArchitecture ))
  {
    $arch = $manifest.ProcessorArchitecture.ToLower()
  }
  Add-Member -InputObject $installer -MemberType NoteProperty -name ProcessorArchitecture -Value $arch
  
  [string]$encodedEULA = $null
  if ($licenseAgreement -ne $null)
  {
    $encodedEULA = Compress-_Resource -data (Get-Content -LiteralPath $licenseAgreement -Encoding Byte)
  }
  Add-Member -inputObject $installer -memberType NoteProperty -name LicenseAgreement      -value $encodedEULA
  
  ## File Data
  [Hashtable]$fileData = @{}
  foreach ($file in $packingList)
  {
    $fileData[$file] = Compress-_Resource -data (Get-Content -LiteralPath (Join-Path $moduleFolder.FullName $file) -Encoding Byte -ErrorAction:Stop)
  }
  
  Add-Member -inputObject $installer -memberType NoteProperty -name FileData -value $fileData
  
  ## Add the methods
  ## The install method unpacks (installs) the PowerShell module described by the object
  ## this method is member of. Parameter 'modulePath' holds the location of the folder
  ## this module is to be intalled into. The value of 'modulePath is typically
  ## '%USERPROFILE%\Documents\WindowsPowerShell'. If 'silent' is set to true, no
  ## license agreement will be displayed 
  Add-Member -InputObject $installer -MemberType ScriptMethod -name Install -value {
    param (
            [parameter(Mandatory=$true, ValueFromPipeline=$false)]
            [System.IO.DirectoryInfo]$modulePath,
            
            [parameter(Mandatory=$true, ValueFromPipeline=$false)]
            [bool]$silent
          )
    if ($this.LicenseAgreement -ne $null)
    {
      [System.IO.FileInfo]$eula = [System.IO.Path]::GetTempFileName()
      $this.Expand($this.LicenseAgreement,$eula)
     
      [string]$licenseAgreement = [string]::Join('
',(Get-Content -LiteralPath $eula.FullName -Encoding UTF8))
      $choices = [Management.Automation.Host.ChoiceDescription[]]@(
          new-Object Management.Automation.Host.ChoiceDescription '&Agree'
          new-Object Management.Automation.Host.ChoiceDescription '&Disagree'
      )
    }
    else
    {
      $silent = $true
    }   
    
    if ($silent -or $host.ui.PromptForChoice('License Agreement',$licenseAgreement,$choices,0) -eq 0)
    {
      # search for an instance of this module
      [string[]]$modulePathes = $null
      if ($modulePath -ne $null)
      {
        $modulePathes = @($modulePath)
      }
      else
      {
        $modulePathes = ($env:PSMODULEPATH).Split(';',[StringSplitOptions]::RemoveEmptyEntries)
      }
      [System.IO.FileInfo]$manifestFile = $null 
      $manifest = $null
      [int]$nCandidates = 0
      foreach ($moduleFolder in $modulePathes)
      {
        [System.IO.FileInfo]$psd1 = Join-Path $moduleFolder "$($this.Name)/$($this.Name).psd1"
        
        if ($psd1.Exists)
        {
          $nCandidates++
          $manifestFile = $psd1
          $manifest = [string]::Join("`n",[System.IO.File]::ReadAllLines($psd1.FullName)) | Invoke-Expression
          if ($manifest.GUID -ne $this.ID)
          {
            throw "A module with name '$($this.Name)' but different ID already exists at location '$($manifestFile.Directory.FullName)'"
          }
          break
        }
        elseif ($manifestFile.FullName -notlike '*Windows\system32*' -or $modulePathes.Length -eq 1)
        {
          $nCandidates++
          $manifestFile = $psd1
        }
      }
      
      if ($nCandidates -eq 0)
      {
        throw "No appropriate install location for module '$($this.Name)' found. Please specify the -modulePath parameter and try again."
      }
      
      if ($nCandidates -gt 1)
      {
        throw "Too many install locations found ($nCandidates). Please specify the -modulePath parameter and try again."
      }
      
      [System.IO.DirectoryInfo]$module = $manifestFile.Directory
      if ($manifest -eq $null)
      {
        $module = [System.IO.Directory]::CreateDirectory($module.FullName)
      }
      
      [int]$i= 0
      foreach ($filename in $this.FileData.Keys)
      {
        Write-Progress -id 0 -Activity "Unpacking Module $($this.Name)" -Status $filename -PercentComplete ((++$i)*100/$this.FileData.Count)
        $this.Expand($this.FileData[$filename],(Join-Path -Path $module.FullName -ChildPath $filename))
      }
      Write-Progress -id 0 -Activity "Unpacking Module $($this.Name)" -Status 'Completed'
      Move-Item -LiteralPath $eula.FullName -Destination (Join-Path $module.Fullname 'EULA.txt') -Force
      $this.GetModuleInfo()
    }
    else
    {
      $eula.Delete()
    }
  }

  ## Utility method to unpack the files embedded with this object.
  ## Parameter 'encodedFileData' is used to pass the compressed and base 64 encoded file ([string])
  ## Parameter 'file' is the location  of the file on disk to store the decoceded and uncompressed
  ## data  
  Add-Member -inputObject $installer -memberType ScriptMethod -name Expand -value {
    param
    (
      [parameter(Mandatory=$true, ValueFromPipeline=$false)]
      [ValidateNotNull()] 
      [string]$encodedFileData,
      
      [parameter(Mandatory=$true, ValueFromPipeline=$false)]
      [ValidateNotNull()]
      [System.IO.FileInfo]$file
    )
    [System.IO.Directory]::CreateDirectory($file.Directory.FullName) | Out-Null
    $decodedDataStream = New-Object System.IO.MemoryStream
    $decoded = [System.Convert]::FromBase64String($encodedFileData)
    $decodedDataStream.Write($decoded,0,$decoded.length)
    $decodedDataStream.Position = 0
    
    $expander = New-Object System.IO.Compression.GZipStream($decodedDataStream, [System.IO.Compression.CompressionMode]::Decompress)
    
    [System.IO.FileStream]$outfile = [System.IO.File]::Create($file.FullName)
    [byte[]]$buffer = New-Object byte[] (1024*64)
    $bytesRead = -1
    
    try
    {
      while ( $bytesRead -ne 0 )
      {
        $bytesRead = $expander.Read($buffer,0,$buffer.length)
        $outfile.Write($buffer,0,$bytesRead)
      }
    }
    finally
    {
      $outfile.Dispose()
      $expander.Dispose()
    }
  }
  Add-Member -inputObject $installer -memberType ScriptMethod -name GetModuleInfo -value {
    return Select-Object -InputObject $this -Property Name,PackagedVersion,Description,Publisher,ProcessorArchitecture
  }
  
  return $installer
}

<#
.SYNOPSIS
Write a PowerShell module installer to disk.

.DESCRIPTION
This command takes an instance on a installer object and writes its definition to disk. This creates
a PowerShell script (.ps1) on disk which, when executed, installs a PowerShell module.

.PARAMETER moduleInstallerInstance
the installer object

.PARAMETER file
location if the PowerShell script on disk to serialize the installer into

.INPUTS
This function does not read from a pipe

.OUTPUTS
nothing
#>
function Export-_PSModuleInstallerInstance ( [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                                             [ValidateNotNull()]
                                             [PSObject]$moduleInstallerInstance,
                                            
                                             [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                                             [ValidateNotNull()]
                                             [System.IO.FileInfo]$file
                                           )
{
  Get-Member -InputObject $moduleInstallerInstance | ForEach-Object `
  -Begin {
@'
param (
  [switch]$silent,
  [switch]$info,
  [System.IO.DirectoryInfo]$modulePath
)
$script:installer = New-Object PSCustomObject
'@
  } `
  -Process {
    $member = $_
    switch ($member.MemberType)
    {
      NoteProperty {
        $value = $moduleInstallerInstance.($member.Name)

        if ($value -is [string])
        {
          "Add-Member -InputObject `$installer -MemberType NoteProperty -Name $($member.Name) -Value '$value'"
        }
        elseif ($value -is [Hashtable])
        {
          "Add-Member -InputObject `$installer -MemberType NoteProperty -Name $($member.Name) -Value @{"
          foreach ($key in $value.Keys)
          {
            "'$key' = '$($value[$key])'" 
          }
          '}'
        }
        elseif ($value -is [System.Version])
        {
          "Add-Member -InputObject `$installer -MemberType NoteProperty -Name $($member.Name) ([System.Version]'$value')"
        }
      }
      
      ScriptMethod {
        "Add-Member -InputObject `$installer -MemberType ScriptMethod -Name $($member.Name) -Value {$($moduleInstallerInstance.$($member.Name).Script) }"
      }
    }
    
  } `  -End {
@'
if ($info)
{
  $installer.GetModuleInfo()
}
else
{
  $installer.Install($modulePath,$silent)
}
'@ 
  } | Set-Content -Path $file.FullName -Encoding UTF8
}

<#
.SYNOPSIS
Convert a string containing dot separated dezimals to [System.Version] 

.DESCRIPTION
Converts a version string to a sanitized dotted quad version object where all
parts of the version are positive dezimals. The string must containd at least two
dot separated decimals (major and minor version).


.PARAMETER moduleVersion
String representing a dot separated version of the form dd.dd[.dd][.dd] where 'd'
represents a decimal number (0..9) and the last two version numbers are optional.

.EXAMPLE
ConvertTo-PSInstallerModuleVersion "1.2"

generates a [System.Version] object like so:

 Major  Minor  Build  Revision
 -----  -----  -----  --------
 1      2      0      0    

.INPUTS
None, this command does not read anything from the pipe

.OUTPUTS
[System.Version] object created from the input string
#>

function ConvertTo-PSInstallerModuleVersion (
                             [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                             [ValidateNotNullOrEmpty()]
                             [string]$moduleVersion
                           )
{
  [System.Version]$version = $moduleVersion
  [int]$major = [Math]::Max($version.Major,0)
  [int]$minor = [Math]::Max($version.Minor,0)
  [int]$build = [Math]::Max($version.Build,0)
  [int]$rev   = [Math]::Max($version.Revision,0)
  return New-Object System.Version $major,$minor,$build,$rev 
}

<#
.SYNOPSIS
Create a self-contained single-file installer for a PowerShell module

.DESCRIPTION
Inspects a given PowerShell module's manifest file and creates a PowerShell script (ps1) which is
able to install the module on a different computer.

The installer script is self contained. It contains an optional custom license agreement, all files mentioned
in the module manifest's FileList section, and finally the module manifest itself as compressed and
encoded strings.

When the installer script is run on the target computer it prompts the user to accept the end user license before
performing the installation, unless the -silent switch is set.

By default the generated installer script does not require any additional information to run, but it understands
following optional command line parameters:

 [-info]    : optional switch. When set the installer script returns application
              information object of the module it contains,but does not install
              anything
 [-silent]  : optional switch. When set installation is performed silently.
              No prompt for acceptance of the license agreement is displayed.
 [-modulesLocation <custom PowerShell modules folder>] : Optional parameter.
              When present the PowerShell module is
              installed to the given location rather than to the standard PowerShell
              modules location at: %USERPROFILE%\Documents\WindowsPowerShell\Modules

.PARAMETER moduleManifest
Path to the module's manifest file (psd1).

.PARAMETER licenseAgreement
Path to a text file containing the end user license a user needs to accept in order to install the module.

.PARAMETER codeSigningCertificate
code signing certificate to sign the module files (ps1, psm1, psd1) in the module as well as the
installer

.PARAMETER installer
path to the installer script to create

.EXAMPLE
 New-PSModuleInstaller -moduleManifest $m -licenseAgreement $l -installer $i

where, for e.g., the used variables are defined as:
 $m = 'D:\Users\<user>\Documents\WindowsPowerShell\Modules\Sync\Sync.psd1'
 $l = 'D:\Users\<user>\Documents\EULA.txt'
 $i = 'e:\scratch\Install-Sync.ps1'

Generates a self-contained, unsigned installer for the 'Sync' module embedding
the license agreement 'EULA.txt'.
The installer script is generated at 'e:\scratch\Install-Sync.ps1' and finally
the corresponding installer object [PSCustomObject] is emitted:

 Name                  : Sync
 PackagedVersion       : 1.1.0.0
 Description           : Data Synchronization Utilities
 Publisher             : WetHat Lab
 ProcessorArchitecture : any

.EXAMPLE
New-PSModuleInstaller -moduleManifest $m -codeSigningCertificate $c -installer $i

where, for e.g., the used variables are defined as:
 $m = 'D:\Users\<user>\Documents\WindowsPowerShell\Modules\Sync\Sync.psd1'
 $c = (get-childitem "cert:CurrentUser\My" -codesigning |`
       Where-Object { $_.Subject -eq "CN=PowerShell User" })
 $i = 'e:\scratch\Install-Sync.ps1'

Generates a self-contained, signed installer for the 'MyModule' module.
All module files including the installer itself are signed with the code
signing certificate whose common name (CN) is 'PowerShell User'.
The installer script is generated at 'e:\scratch\Install-Sync.ps1' and
finally the corresponding installer object [PSCustomObject] is emitted:

 Name                  : Sync
 PackagedVersion       : 1.1.0.0
 Description           : Data Synchronization Utilities
 Publisher             : WetHat Lab
 ProcessorArchitecture : any
 
.INPUTS
None. This funtions does not read anything from a pipe

.OUTPUTS
Application info object [PSCustomObject] describing the packaged module. The info object has these properties:
  Name:            Module Name
  PackagedVersion: Module Version
  Description:     Module Description
  Publisher:       Name of the company that published the module
  ProcessorArchitecure: 'any' if all architecures are supported, x86 or amd64 otherwise
#>
function New-PSModuleInstaller ( [parameter(Mandatory=$true, ValueFromPipeline=$true)]
                                 [ValidateNotNull()]
                                 [ValidateScript({$_.Exists})]
                                 [System.IO.FileInfo]$moduleManifest,
                                 
                                 [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                                 [ValidateScript({$_ -eq $null -or $_.Exists})]
                                 [System.IO.FileInfo]$licenseAgreement,
                                 
                                 [parameter(Mandatory=$false, ValueFromPipeline=$false)]
                                 [System.Security.Cryptography.X509Certificates.X509Certificate2]$codeSigningCertificate,
                                 
                                 [parameter(Mandatory=$true, ValueFromPipeline=$false)]
                                 [ValidateNotNull()]
                                 [System.IO.FileInfo]$installer
                               )
{
  Process
  {
    $manifest = [string]::Join("`n",[System.IO.File]::ReadAllLines($moduleManifest.FullName)) | Invoke-Expression
    $manifest.ModuleVersion = ConvertTo-PSInstallerModuleVersion -moduleVersion $manifest.ModuleVersion
  
    [string[]]$packingList = @($manifest.FileList) + $moduleManifest.Name
    [System.IO.DirectoryInfo]$module = $moduleManifest.Directory
  
    ## if we have a certificate we sign all scripts and manifests
    if ($codeSigningCertificate -ne $null)
    {
      $packingList | Where-Object { $_ -like '*.ps*1'} | ForEach-Object {
        Join-Path -Path  $module.FullName $_
      } | Set-AuthenticodeSignature  -Certificate $codeSigningCertificate | out-string | Write-Host -ForegroundColor Gray
    }

    $installerInstance = New-_PSModuleInstallerInstance -moduleFolder $module `                                                        -manifest $manifest `
                                                        -packingList $packingList `
                                                        -licenseAgreement $licenseAgreement
                                                     
    Export-_PSModuleInstallerInstance -moduleInstallerinstance $installerInstance -file $installer
    if ($codeSigningCertificate -ne $null)
    {
      Set-AuthenticodeSignature  -Certificate $codeSigningCertificate  -FilePath $installer.FullName | out-string | Write-Host -ForegroundColor Gray
    }
    return $installerInstance.GetModuleInfo()
  }
}
